// This source code is subject to the terms of the Mozilla Public License 2.0 at https://mozilla.org/MPL/2.0/
// © capissimo

//@version=4
study("Machine Learning: kNN-based Strategy (update)", '', true, precision=4, max_labels_count=200)

// kNN-based Strategy (FX and Crypto)
// Description: 

/////////////////////////////////////////////////////////////////////////////////
// This update to the popular kNN-based strategy features:
//     improvements in the business logic,
//     an adjustible k value for the kNN model, 
//     one more feature (MOM),
//     a streamlined signal filter and 
//     some other minor fixes.
//
// Now this script works in all timeframes !
//
// I intentionally decided to publish this script separately 
// in order for the users to see the differences.
/////////////////////////////////////////////////////////////////////////////////

// This strategy uses a classic machine learning algorithm - k Nearest Neighbours (kNN) - 
// to let you find a prediction for the next (tomorrow's, next month's, etc.) market move. 
// Being an unsupervised machine learning algorithm, kNN is one of the most simple learning algorithms. 

// To do a prediction of the next market move, the kNN algorithm uses the historic data, 
// collected in 3 arrays - feature1, feature2 and directions, - and finds the k-nearest 
// neighbours of the current indicator(s) values. 

// The two dimensional kNN algorithm just has a look on what has happened in the past when 
// the two indicators had a similar level. It then looks at the k nearest neighbours, 
// sees their state and thus classifies the current point.

// The kNN algorithm offers a framework to test all kinds of indicators easily to see if they 
// have got any *predictive value*. One can easily add cog, wpr and others.
// Note: TradingViews's playback feature helps to see this strategy in action.

// Style tags: Trend Following, Trend Analysis
// Asset class: Equities, Futures, ETFs, Currencies and Commodities
// Dataset: FX Minutes/Hours+++/Days

//-------------------- Inputs

K          = input(63,    'K Value for kNN Model |5..n|', minval=5)
ind        = input('All', 'Indicator',                    options=['RSI','ROC','CCI','MOM','All'])
fast       = input(14,    'Fast Period |1..n|',           minval=1)
slow       = input(28,    'Slow Period |2..n|',           minval=2)
ftype      = input('Both','Filter Signals by',            options=['Volatility','Volume','Both','None'])
holding_p  = input(1,     'Holding Period |1..n|',        minval=1)
tthres     = input(99.9,  'Time Threshold |0.1..100.0|',  minval=0.0, maxval=100.0, step=0.1)

//-------------------- Global Variables

var int BUY  =  1
var int SELL = -1
var int HOLD =  0
var int hp_counter = 0

var float k  = floor(sqrt(K))  

//-------------------- Custom Functions

cAqua(g) => g>9?#0080FFff:g>8?#0080FFe5:g>7?#0080FFcc:g>6?#0080FFb2:g>5?#0080FF99:g>4?#0080FF7f:g>3?#0080FF66:g>2?#0080FF4c:g>1?#0080FF33:#00C0FF19
cPink(g) => g>9?#FF0080ff:g>8?#FF0080e5:g>7?#FF0080cc:g>6?#FF0080b2:g>5?#FF008099:g>4?#FF00807f:g>3?#FF008066:g>2?#FF00804c:g>1?#FF008033:#FF008019

volumeBreak(thres) =>  
    rsivol   = rsi(volume, 14)  
    osc      = hma(rsivol, 10)  
    osc > thres                     

volatilityBreak(volmin, volmax) => atr(volmin) > atr(volmax)

scale(x, p) => (x - lowest(x, p)) / (highest(x, p) - lowest(x, p)) //-- scale to the range of [0..1]

//-------------------- Logic

// 4 pairs of predictor indicators, long and short each
rs = rsi(close, slow), rf = rsi(close, fast)
cs = cci(close, slow), cf = cci(close, fast)
os = roc(close, slow), of = roc(close, fast)
// added mom as a feature: 
// it requires scaling to the range of [0,...,100]
// the choice of 63 is arbitrary, needs optimizing
ms = scale(mom(close, slow), 63) * 100, mf = scale(mom(close, fast), 63) * 100  

// TOADD or TOTRYOUT:
//    cmo(close, slow), cmo(close, fast)
//    mfi(close, slow), mfi(close, fast)
//    mom(close, slow), mom(close, fast)
f1 = ind=='RSI' ? rs : ind=='ROC' ? os : ind=='CCI' ? cs : ind=='MOM' ? ms : avg(rs, os, cs, ms)
f2 = ind=='RSI' ? rf : ind=='ROC' ? of : ind=='CCI' ? cf : ind=='MOM' ? mf : avg(rf, of, cf, mf)

// Classification data, what happens on the next bar
class = close[1]<close[0] ? SELL: close[1]>close[0] ? BUY : HOLD

// Training data, normalized to the range of [0,...,100]
var feature1   = array.new_float(0)  // [0,...,100]
var feature2   = array.new_float(0)  //    ...
var directions = array.new_int(0)    // [-1; +1]

// Result data
var predictions = array.new_int(0)
var prediction  = 0.
var signal      = HOLD

// Store everything in arrays. Features represent a square 100 x 100 matrix,
// whose row-colum intersections represent class labels, showing historic directions
array.push(feature1, f1)
array.push(feature2, f2)
array.push(directions, class)

// Ucomment the followng statement (if barstate.islast) and tab everything below
// between BOBlock and EOBlock marks to see just the recent several signals gradually 
// showing up, rather than all the preceding signals

//if barstate.islast   

//==BOBlock	

// Core logic of the algorithm

size   = array.size(directions)
maxdist = -999.

// Loop through the training arrays, getting distances and corresponding directions.
for i=0 to size-1
    // Calculate the euclidean distance of current point to all historic points,
    // here the metric used might as well be a manhattan distance or any other.
    d = sqrt(pow(f1 - array.get(feature1, i), 2) + pow(f2 - array.get(feature2, i), 2))
    
    if d > maxdist
        maxdist := d
        if array.size(predictions) >= k
            array.shift(predictions)
        array.push(predictions, array.get(directions, i))
        
//==EOBlock	

// Note: in this setup there's no need for distances array (i.e. array.push(distances, d)),
//       but the drawback is that a sudden max value may shadow all the subsequent values.
// One of the ways to bypass this is to:
// 1) store d in distances array,
// 2) calculate newdirs = bubbleSort(distances, directions), and then 
// 3) take a slice with array.slice(newdirs) from the end
    	
// Get the overall prediction of k nearest neighbours
prediction := array.sum(predictions)   
    
// Now that we got a prediction for the next market move, we need to make use of this prediction and 
// trade it. The returns then will show if everything works as predicted.
// Over here is a simple long/short interpretation of the prediction, 
// but of course one could also use the quality of the prediction (+5 or +1) in some sort of way,
// ex. for position sizing.

tbase   = (time - time[1]) / 1000
tcurr   = (timenow - time_close[1]) / 1000
barlife = tcurr / tbase

filter =   ftype=='Volatility' ? volatilityBreak(1, 10) 
         : ftype=='Volume'     ? volumeBreak(49) 
         : ftype=='Both'       ? volatilityBreak(1, 10) and volumeBreak(49)
         :                       true
         
signal := prediction > 0 and barlife > tthres and filter ? BUY : prediction < 0 and barlife > tthres and filter ? SELL : nz(signal[1])  // HOLD

changed = change(signal)

hp_counter := changed ? 0 : hp_counter + 1

startLongTrade  = changed and signal==BUY    
startShortTrade = changed and signal==SELL   
endLongTrade    = (changed and signal==SELL) or (signal==BUY  and hp_counter==holding_p and not changed) 
endShortTrade   = (changed and signal==BUY)  or (signal==SELL and hp_counter==holding_p and not changed) 

//-------------------- Rendering

plotshape(startLongTrade  ? low  : na, 'Buy',  shape.labelup,   location.belowbar, cAqua(prediction*5),  size=size.small)  // color intensity correction
plotshape(startShortTrade ? high : na, 'Sell', shape.labeldown, location.abovebar, cPink(-prediction*5), size=size.small)
plot(endLongTrade         ? high : na, 'StopBuy',  cAqua(6), 2, plot.style_cross)
plot(endShortTrade        ? low  : na, 'StopSell', cPink(6), 2, plot.style_cross)

//-------------------- Notification

if changed and signal==BUY 
    alert("Buy Alert", alert.freq_once_per_bar)  // alert.freq_once_per_bar_close
if changed and signal==SELL 
    alert("Sell Alert", alert.freq_once_per_bar)
    
alertcondition(startLongTrade,  "Buy Alert",  "Go Long!")
alertcondition(startShortTrade, "Sell Alert", "Go Short!")

//-------------------- Backtesting

show_info = input(true, '===Information===')
lot_size  = input(0.01,  'Lot Size', options=[0.01,0.1,0.2,0.3,0.5,1,2,3,5,10,20,30,50,100,1000]) 

bidask  = (open+high+low)/3

var float start_long_trade  = bidask  
var float long_trades       = 0.
var float start_short_trade = bidask  
var float short_trades      = 0.
var int   wins              = 0
var int   trade_count       = 0

if startLongTrade 
    start_long_trade := bidask 
if endLongTrade
    ldiff         = (bidask - start_long_trade)
    wins         := ldiff > 0 ? 1 : 0
    long_trades  := ldiff * lot_size   
    trade_count  := 1
if startShortTrade 
    start_short_trade := bidask
if endShortTrade
    sdiff         = (start_short_trade - bidask)
    wins         := sdiff > 0 ? 1 : 0
    short_trades := sdiff * lot_size 
    trade_count  := 1
    
cumreturn   = cum(long_trades) + cum(short_trades)  //-- cumulative return 
totaltrades = cum(trade_count)
totalwins   = cum(wins)  
totallosses = totaltrades - totalwins == 0 ? 1 : totaltrades - totalwins

//------------------- Information

var label lbl = na

info =  'K Value: '    + tostring(K, '#') + ' (' + tostring(k, '#.##') + ')'
      + '\nHPeriod: '  + tostring(holding_p,             '#') 
      + '\nCR='        + tostring(cumreturn,             '#.#') 
      + '\nTrades: '   + tostring(totaltrades,           '#')
      + '\nWin/Loss: ' + tostring(totalwins/totallosses, '#.##') 
      + '\nWinrate: '  + tostring(totalwins/totaltrades, '#.#%')       
      + '\nBar Time: ' + tostring(barlife,               '#.#%')

if show_info and barstate.islast  
    lbl := label.new(bar_index, ohlc4, info, xloc.bar_index, yloc.price, 
                     color.new(color.blue, 100), label.style_label_left, color.black, size.small, text.align_left)
    label.delete(lbl[1])